Repository navigation
v0.72.0: restore redacted values in the form and depth each place needs - #334
Merged
Merged
Conversation
…ex custom tool input in streams The ledger now records every original decoded, whatever form it was found in: a value cut from the raw JSON body (a PEM block whose newlines are `\n`, a connection-string password with `\\`) is unescaped once at issue time; a value found in decoded text (plugin bridge, trial sample) is kept as written. Restoring writes it back for the destination: - `restore` (whole JSON text: non-streamed answers, WebSocket frames, replays) JSON-escapes it exactly once; values without escapes are put back byte for byte as before. `restore_json` is folded into it. - `restore_text` (new) puts the decoded value into decoded text; the plugin bridge uses it. - `FrameRestorer` puts the decoded value into text fields and the escaped one into tool-argument JSON (`arguments`, `partial_json`, Converse `toolUse.input`), streamed or whole. - `apply_text` (new) is the issuer for decoded text; `apply` stays the issuer for JSON text. Lookups that start from raw JSON text match the escaped form: stored-body masking keys its table by the JSON-written value (decoded for carried summaries), and security-log detail unescapes before `placeholder_of`. The same value seen raw in the body and decoded by the plugin layer now shares one placeholder number. The Responses stream restorer also gets a lane for `response.custom_tool_call_input.delta` (Codex freeform tools such as `apply_patch`), closed by `response.custom_tool_call_input.done` and `response.output_item.done`; a held tail is flushed as a custom tool call input delta, not a function-call arguments delta. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eholders split across WebSocket frames
Whole JSON text (non-streamed answers, replays, the non-SSE byte stream)
is now read structurally while restoring: a small JSON reader tracks
which string a placeholder sits in, and a placeholder inside a string
that is itself JSON text (Chat/Responses `arguments`, Anthropic
`partial_json`, Converse `toolUse.input`, when the string starts with
`{` or `[`) gets the value escaped twice; elsewhere once. Only the
placeholders change; every other byte stays as it was, and the reader's
state carries across chunks so `ByteRestorer` needs no buffering. Text
that is not JSON is restored as before (escaped once).
`FrameRestorer` leaves streamed fields to their lane: a whole
placeholder inside a single arguments delta used to be restored by the
frame-wide pass as plain text. Complete `arguments` strings are treated
as JSON only when they look like JSON, so a tool's own parameter named
`arguments` holding a sentence is restored as text.
WebSocket upstream frames go through a per-connection `FrameRestorer`
(Responses events): a placeholder split across delta frames is held and
restored, tails are flushed as their own lane's events before the frame
that closes the lane (or when the upstream goes away), and the restorer
is rebuilt when the ledger grows mid-connection, flushing what it held
first. Frames that are not JSON are restored whole as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… stored values decoded; nest JSON-in-string to any depth - WebSocket: when a later client frame adds values to the connection's ledger, the frame restorer now switches to the new ledger and keeps every lane's held tail (`FrameRestorer::rebook`, `Restorer::rebook`) instead of flushing them, so `<<TW_SEC` held before the switch still joins `RET_1>>` from the next frame. Tails flushed when the upstream goes away now go through the same path as other frames (model-name rewriting, reply hooks, tool wall). - Stored bodies and security-log contexts look values up decoded: the raw hit is unescaped before matching the ledger, so a value the client wrote with other escapes (`\u00e9`, `\/`) is stored as the placeholder the upstream received rather than masked. - The structural reader tracks JSON-in-string recursively: a tool's own `arguments` (or `partial_json`, `toolUse.input`) field holding JSON text inside the arguments is one more level deep, at any depth. Whole restores and the streamed argument lanes agree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fylorn
force-pushed
the
fix/restore-escapes-and-custom-tool-input
branch
from
October 12, 2026 08:42
b3c7732 to
943e450
Compare
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What was wrong
Escaped originals. Outbound redaction finds values in the raw JSON body, so the ledger kept them with their JSON escapes. A PEM private key's newlines are
\nthere (two characters); a connection-string password can carry\\. When the model echoed the placeholder in a streamed answer,FrameRestorerput that raw form into a decoded string, so the client got a literal backslash-n instead of a newline.restore_jsonescaped the raw form again (double escaping), and the plugin bridge revealed raw forms into decoded text and failed to recognise the same key in decoded text (it was issued a second number).Nested JSON. Whole-JSON restore (non-streamed answers, replays, WebSocket frames) escaped every value once, but a placeholder inside tool-call
argumentssits in a string that is itself JSON text and needs escaping twice; the client's arguments did not parse.Codex custom tool input. The Responses stream restorer had lanes for
output_textandfunction_call_arguments, but notresponse.custom_tool_call_input.delta(freeform tools such asapply_patch). A placeholder split across those deltas reached the client unrestored.WebSocket. Upstream frames were restored one by one on raw text, so a placeholder split across two delta frames was never restored.
Design
The ledger holds decoded originals
Each original is recorded decoded, whatever form it was found in:
apply/redact/number(request body, WS frames,flow::look/replace)apply_text/redact_text(new; plugin bridgescan_text, trialhits_plain)Normalising at issue time (rather than tagging each entry with its form) also means the same value seen raw in the body and decoded by the plugin layer shares one placeholder number. Lookups that start from raw JSON text compare decoded values: stored-body masking and security-log contexts unescape the raw hit before looking it up, and security-log detail unescapes before
placeholder_of. A value the client wrote with other escapes (\u00e9from Python,\/from PHP) is therefore stored as the placeholder the upstream received, not masked.Every restore path writes the form and depth its destination needs
restore(whole JSON: non-streamed answers, replay, non-JSON WS frames) andRestorer::new/ByteRestorerrestore_text(new; plugin bridge),Restorer::text(new)FrameRestorertext fields, custom tool input laneFrameRestorertool-argument lanes and completeargumentsstringsStructural whole-JSON restore. A small JSON reader (
redact::nest) runs alongside the swap and tracks which string each placeholder sits in. A placeholder inside a string that is itself JSON text (Chat/Responsesarguments, Anthropicpartial_json, ConversetoolUse.input, and only when that string starts with{or[, so a tool's own parameter namedargumentsholding a sentence stays text) gets the value escaped twice; elsewhere once. The reader nests recursively: a tool's ownargumentsfield holding JSON text inside the arguments is one level deeper again, at any depth, and the streamed argument lanes (which read the decoded arguments with the same reader) agree with the whole-body restore. The JSON is not parsed and re-serialised: only the placeholders change, every other byte stays as it was (é, whitespace and key order included), bodies without placeholders are untouched, and the reader's state carries across chunks so the non-SSE byte stream needs no buffering. Text that is not JSON (it does not start with{,[or") is restored as before, escaped once.FrameRestorernow leaves streamed fields to their lane: a whole placeholder inside a single arguments delta used to be restored by the frame-wide pass as plain text.Custom tool call input
New
Input(output_index)lane forresponse.custom_tool_call_input.delta, closed byresponse.custom_tool_call_input.doneandresponse.output_item.done; a held tail is synthesized as aresponse.custom_tool_call_input.delta(same shape the dialect converter emits), not a function-call arguments delta. The lane restores the decoded value (freeform input is plain text).WebSocket
Upstream text frames go through a per-connection
FrameRestorer(Responses events), the same restorer as the SSE path: a placeholder split across delta frames is held per lane and restored; held tails are flushed as their lane's own events before the frame that closes the lane, or when the upstream goes away. When the ledger grows mid-connection (a later client frame carries a new value) the restorer switches to the new ledger and keeps every lane's held tail (FrameRestorer::rebook): the new ledger is a superset, so a held<<TW_SECstill joinsRET_1>>from the next frame. Synthesized frames, including tails flushed when the upstream goes away, pass through model renaming, reply hooks and the tool wall like any other frame. Frames that are not JSON are restored whole withrestore.Public API (tw-guard)
replace::Form,replace::json_escaped,replace::apply_text,replace::restore_text,stream::Restorer::text,stream::Restorer::fresh_as,stream::Restorer::rebook,sse::FrameRestorer::rebook.replace::restore_json(folded intorestore).Ledger::table/replacements/placeholder_ofhold decoded originals;restoreandRestorer::newescape by depth.flow::{look, replace, hits, hits_plain},replace::{Ledger, restore},sse::{FrameRestorer, Synth}andrules::*only; none of those signatures changed, and itsrestore/FrameRestorercallers get the fixes as they are.Tests
restoreround-trips the body byte for byte,restore_textgives real newlines; escaped connection-string password; a literal backslash in decoded text survives; same value raw and decoded shares one number; trial sample with a typed\u0041is not unescaped.arguments, ConversetoolUse.input, escaped quotes and\u0022inside arguments, a tool parameter namedargumentsholding a sentence, Anthropic object input, non-JSON text, and the same levels however the text is cut; whole Chat/Responses/Converse/Anthropic answers restore to arguments that parse back to real newlines; a tool's ownargumentsfield holding JSON parses back to real newlines at the innermost level, and streaming those arguments in 1/3/7/16-byte Chat deltas or a whole.donegives the same arguments as the whole-body restore; only placeholders move (\u00e9and whitespace kept);ByteRestorercut at every size from 1 to 39 bytes equals the whole restore.output_textdeltas (Responses, Anthropic) gives real newlines; in function-call arguments (Responses deltas,.done,output_item.done, Chat, Anthropicpartial_json) the arguments parse to real newlines; a whole placeholder in one arguments delta is escaped for the arguments; whole JSON answer escapes once; custom tool call input split across deltas is restored (deltas,.done,output_item.done) and a held tail is flushed asresponse.custom_tool_call_input.deltabefore.done/output_item.done/ end of stream.\u00e9/\/escapes serde would not produce; security-log detail names the placeholder of an escaped value and shows placeholders in contexts for such values..doneframes carry it too; a half placeholder held while a later client frame grows the ledger is still joined with the next frame; a tail held when the upstream goes away reaches the client as a text delta.cargo test --workspace,cargo clippy --workspace --all-targets -- -D warnings,cargo fmt --all -- --checkpass.🤖 Generated with Claude Code